feat(ape): add Agent Policy Engine extension#70
feat(ape): add Agent Policy Engine extension#70Lightheartdevs merged 7 commits intoLight-Heart-Labs:mainfrom
Conversation
APE is a lightweight policy gateway for Dream Server's autonomous agent
framework (OpenClaw). It intercepts tool calls before execution and
evaluates them against a configurable policy, providing:
- Intent classification: ExecuteCommand / WriteFile / ReadFile /
NetworkFetch / SpawnAgent / Other — inferred from tool name and args
- Allowlist enforcement: only approved commands execute; unsafe
patterns (rm -rf, curl|sh, netcat shells) are blocked by regex
- Path guards: WriteFile restricted to /workspace and /tmp/openclaw
- Rate limiting: configurable RPM cap across all agent sessions
- Audit log: every decision appended to audit.jsonl (append-only,
never mutated) with tool name, intent, args keys, session ID,
agent ID, and timestamp
- Hot-reload: policy.yaml changes are picked up without restarting
API surface:
POST /verify — evaluate a tool call, returns allowed + reason
GET /audit — tail the audit log (last N entries)
GET /policy — active policy summary (no sensitive values)
GET /metrics — allowed/denied/rate_limited counters
GET /health — liveness probe
Enable with:
dream enable ape
OpenClaw integration: point OpenClaw's tool middleware at
http://ape:7890/verify. APE runs non-blocking by default (STRICT_MODE
can be set to enforce hard blocks). The port binds to 127.0.0.1 only.
Footprint: ~256 MB RAM limit, 0.5 CPU, Python 3.12-slim image,
no GPU required.
The full APE engine with formal Rocq/Coq conscience proofs (G1-G6) and trust algebra is open-source at: https://github.com/latentcollapse/HLX_research_language (AGPL v3) This extension is a lightweight Python reimplementation of the core policy verification concept, designed to fit Dream Server's extension architecture. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lightheartdevs
left a comment
There was a problem hiding this comment.
Good work — clean compose config, pinned deps, localhost binding, no-new-privileges, resource limits, hot-reload. This is solid for a v1 optional extension.
One required change before merge:
path_guard in main.py uses raw str.startswith() without path normalization:
if any(path.startswith(p) for p in allowed_paths):A path like /home/node/.openclaw/workspace/../../../etc/passwd passes this check because it starts with the allowed prefix before traversal. Fix:
import posixpath
# ... in the path_guard block:
normalized = posixpath.normpath(path)
if any(normalized.startswith(p) for p in allowed_paths):This is a one-line fix. Since APE is specifically a security extension, it should ship without a path traversal bypass.
Everything else (in-memory rate limiting, hardcoded CORS, no auth on /verify) is acceptable for v1. Please push the normpath fix and we'll merge.
str.startswith() on a raw path allows traversal bypasses: /home/node/.openclaw/workspace/../../../etc/passwd passes the check even though it resolves outside the allowed zone. Use posixpath.normpath() to resolve .. segments before comparing against allowed_paths. Since APE is a security extension it should ship without this bypass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lightheartdevs
left a comment
There was a problem hiding this comment.
Review: Security vulnerabilities in the policy engine
Well-architected extension — the intent classification → policy evaluation → audit trail pipeline is clean, the Docker compose hardening is excellent (loopback binding, no-new-privileges, resource limits, pinned deps), and the hot-reload via mtime check is efficient. The CORS restriction and audit JSONL format are good choices.
However, for a security-focused service, there are two exploitable bypasses that need fixing:
🔴 Path traversal via symlinks + prefix matching
The path_guard mode validates paths like this:
normalized = posixpath.normpath(path)
if any(normalized.startswith(p) for p in allowed_paths):
return TrueTwo bypass vectors:
-
Symlink escape: If
/tmp/openclaw/evilis a symlink to/etc/shadow,normpathresolves..but does not resolve symlinks. The path passes the check (starts with/tmp/openclaw) but the write follows the symlink to/etc/shadow. -
Prefix collision: With
allowed_paths: ["/tmp"], the path/tmpevil/payloadpasses because"/tmpevil/payload".startswith("/tmp")isTrue.
Fix:
normalized = os.path.realpath(path) # resolves symlinks
if any(normalized == p or normalized.startswith(p + "/") for p in allowed_paths):
return True🔴 Empty command bypasses the allowlist
if not command:
return True, "no command specified"When the policy mode is allowlist, an empty or missing command field results in allow. An attacker can craft a tool call with the command in a different key (e.g., "cmd" vs "command", or embedded in another field) that the downstream tool executor processes but APE doesn't see.
Fix: Under allowlist mode, an empty command should deny, not allow:
if not command:
return False, "no command specified (required for allowlist policy)"⚠️ Default policy allows python3 and node
The shipped policy.yaml allowlists python3 and node, which enable arbitrary code execution — the exact threat APE is designed to prevent. An agent can python3 -c "import os; os.system('rm -rf /')" and it passes the allowlist check (base command is python3, which is allowed).
Fix: Remove python3 and node from the default allowlist. If users need them, they can add them explicitly and accept the risk.
⚠️ Intent classification false positives
Substring matching causes misclassifications:
"brush"contains"sh"→ misclassified as ExecuteCommand"dashboard_read"contains"read"→ misclassified as ReadFile
Consider using word boundary matching or an exact-match set for tool name classification.
Minor issues
-
Rate limiter is global, not per-session: One busy agent can exhaust the limit for all agents. Consider per-
session_idbuckets. -
0.0.0.0in main.py:uvicorn.run(app, host="0.0.0.0")binds to all interfaces. Docker's port mapping (127.0.0.1:7890:7890) prevents external access, but the Python code should also bind to127.0.0.1for defense in depth. -
Manifest
gpu_backends: Only lists[amd, nvidia]. After PR #120 lands this won't matter for apple (all Docker services load), but it's inconsistent with the broader push to add apple support.
The architecture is sound and the audit/metrics design is production-quality. The path traversal and empty command bypasses are the priority fixes — the rest are hardening suggestions.
|
Thank you for the feedback. I'm experiencing some network issues at the
moment, but the fixes are noted and I'll make those changes as soon as I'm
able. It's Python recreation of my Rust based APE engine.
…On Tue, Mar 10, 2026, 11:10 AM Lightheartdevs ***@***.***> wrote:
***@***.**** requested changes on this pull request.
Review: Security vulnerabilities in the policy engine
Well-architected extension — the intent classification → policy evaluation
→ audit trail pipeline is clean, the Docker compose hardening is excellent
(loopback binding, no-new-privileges, resource limits, pinned deps), and
the hot-reload via mtime check is efficient. The CORS restriction and audit
JSONL format are good choices.
However, for a security-focused service, there are two exploitable
bypasses that need fixing:
🔴 Path traversal via symlinks + prefix matching
The path_guard mode validates paths like this:
normalized = posixpath.normpath(path)if any(normalized.startswith(p) for p in allowed_paths):
return True
*Two bypass vectors:*
1.
*Symlink escape:* If /tmp/openclaw/evil is a symlink to /etc/shadow,
normpath resolves .. but *does not resolve symlinks*. The path passes
the check (starts with /tmp/openclaw) but the write follows the
symlink to /etc/shadow.
2.
*Prefix collision:* With allowed_paths: ["/tmp"], the path
/tmpevil/payload passes because "/tmpevil/payload".startswith("/tmp")
is True.
*Fix:*
normalized = os.path.realpath(path) # resolves symlinksif any(normalized == p or normalized.startswith(p + "/") for p in allowed_paths):
return True
🔴 Empty command bypasses the allowlist
if not command:
return True, "no command specified"
When the policy mode is allowlist, an empty or missing command field
results in *allow*. An attacker can craft a tool call with the command in
a different key (e.g., "cmd" vs "command", or embedded in another field)
that the downstream tool executor processes but APE doesn't see.
*Fix:* Under allowlist mode, an empty command should *deny*, not allow:
if not command:
return False, "no command specified (required for allowlist policy)"
|
- Remove python3/node from default command allowlist (arbitrary code exec) - Deny empty commands instead of allowing them (allowlist bypass) - Use word-boundary token matching for intent classification (false positives on tool names like "bash_tools_wrapper" or "get_shell_result") - Switch to per-session rate limiting (global deque let one agent exhaust quota for all others) - Replace posixpath.normpath with os.path.realpath for path_guard (normpath does not resolve symlinks; also fix prefix collision: /tmp matching /tmp-evil) - Bind uvicorn to 127.0.0.1 instead of 0.0.0.0 - Add apple to gpu_backends in manifest.yaml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Lightheartdevs
left a comment
There was a problem hiding this comment.
Request Changes
The architecture is well-designed and directly addresses H3 from PR #71. Three issues to fix before merge.
🔴 Dockerfile runs as root
A security enforcement service should not run as root in its own container. The Dockerfile has no USER directive. One-line fix:
RUN adduser --system --no-create-home ape
USER apeThis is consistent with the project's existing pattern — dashboard-api runs as dreamer:1000, ComfyUI as comfyui:1000.
🔴 No authentication on APE's own endpoints
POST /verify and GET /audit are unauthenticated. Since APE runs on the Docker network, any container can reach it via ape:7890 — including a compromised agent, n8n workflow, or any other service on dream-network. An attacker could:
- Probe policy details via
/verifyto map the allowlist - Read the full audit log via
/audit
Add a shared-secret header check (validated against an env var like APE_API_KEY), consistent with how dashboard-api handles auth.
🟡 Missing OpenClaw integration documentation
The PR description says "Point OpenClaw's tool middleware at http://ape:7890/verify" but provides no config change, compose override, or step-by-step to wire this up. An operator who runs dream enable ape gets a running but disconnected service. Either:
- Add a compose override that configures OpenClaw's middleware URL, or
- Add a setup section in the README/manifest with explicit steps
🟡 Default advisory mode needs a prominent warning
APE_STRICT_MODE=false (the default) means APE is purely advisory — it logs decisions but never blocks anything. An operator who deploys APE assuming it enforces policy has a false sense of security. Add a startup log line like:
WARNING: APE is running in advisory mode. Tool calls are logged but NOT blocked. Set APE_STRICT_MODE=true to enforce policies.
🟡 decision_id entropy is too low
decision_id = f"{int(time.time() * 1000)}-{id(req) & 0xFFFF:04x}"id(req) cycles through a small range (CPython reuses memory addresses for request objects). The 4-digit hex suffix provides only 65,536 possible values — collisions within the same millisecond are plausible in high-volume logs. Use secrets.token_hex(8) or uuid.uuid4().
🟡 /audit endpoint loads entire log into memory
lines = AUDIT_LOG.read_text().strip().splitlines()
entries = [json.loads(l) for l in lines[-last_n:] if l.strip()]For a long-running system with a large audit log, this loads the entire file before slicing. Use a tail-read approach (seek from end) or a collections.deque(maxlen=last_n) reader.
What's good
- YAML policy hot-reload via mtime check — no restart needed to update rules
- Intent classification with verb-set matching and args-based fallback is a reasonable heuristic
- Path guards use
os.path.realpath()for symlink resolution — correct approach to prevent traversal - Append-only audit log with structured JSON entries is the right pattern for forensics
- Rate limiting per session with configurable window
- Pinned dependencies in
requirements.txt— good for reproducibility 127.0.0.1binding andno-new-privileges:true— correct security posturemanifest.yamlcorrectly marks the service asoptionalwith explicitdream enable aperequired
- APE: Run as non-root user (ape) in container - APE: Add API key authentication on /verify and /audit endpoints - APE: Auto-generate API key at startup if not provided - APE: Add advisory mode warning at startup - APE: Fix decision_id entropy using secrets.token_hex(8) - APE: Use tail-read for /audit endpoint to avoid loading entire file - HVAC: Redact hardcoded LiveKit credentials, use env vars instead
Resolved conflict in hvac-token-server.py
These enable arbitrary code execution - users can add them explicitly if needed
Lightheartdevs
left a comment
There was a problem hiding this comment.
Clean security extension that addresses Issue #22 (unrestricted agent exec).
Well-architected: FastAPI proxy with YAML policy config, rate limiting, non-root Dockerfile, compose integration. The policy allowlist for exec, path guards for writes, and deny patterns for dangerous commands are all sensible defaults. 7 files, manageable scope.
Overview
APE is a lightweight policy gateway for Dream Server's autonomous agent framework. It intercepts OpenClaw tool calls before execution and evaluates them against a configurable policy.
Motivation
OpenClaw's
exectool can run arbitrary commands on the host machine. Issue #22 showed the gateway was publicly accessible — fixing the binding is necessary but not sufficient. A policy layer gives operators fine-grained control over what an agent is allowed to do, not just who can reach it.What it provides
ExecuteCommand / WriteFile / ReadFile / NetworkFetch / SpawnAgent / Otherfrom tool name and argsrm -rf,curl|sh, netcat reverse shells) blocked by regexWriteFilerestricted to/workspaceand/tmp/openclawby defaultaudit.jsonl(append-only, never mutated) with tool name, intent, args keys, session ID, agent ID, and timestamppolicy.yaml, APE picks it up within ~30s, no restart neededAPE_STRICT_MODE=truemakes denials return HTTP 403; default is non-blocking (log and advise)API
Usage
dream enable apePoint OpenClaw's tool middleware at
http://ape:7890/verify. Runs non-blocking by default so existing workflows are unaffected until you're ready to enforce.Footprint
127.0.0.1only